Skip to content

15 类型编程综合实战二

想提升类型编程水平,还是要多做一些有难度的案例,这节我们提高下难度,加大训练强度。

函数重载的三种写法

ts 支持函数重载,也就是同名的函数可以有多种类型定义。

重载的写法一共有三种:

ts
declare function func(name: string): string;
declare function func(name: number): number;

这种大家比较常用,声明两个同名函数,就能达到重载的目的:

image

当然,如果有函数的实现,那就不用带 declare 了:

ts
function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: any, b: any) {
  return a + b;
}

当传入 number 参数时:

image

传入 string 参数时:

image

函数可以用 interface 的方式声明,同样,也可以用 interface 的方式声明函数重载:

image

函数类型可以取交叉类型,也就是多种类型都可以,其实也是函数重载的意思:

image

试一下

声明多个同名函数类型、interface 声明多个函数签名、交叉类型,一共这三种函数重载的方式。

这里讲函数重载是为了下面这个复杂类型做铺垫的:

UnionToTuple

要求把联合类型转成元组类型,大家有思路没?

也就是 'a' | 'b' | 'c' 转成 ['a', 'b', 'c']。

没思路很正常,因为这里用到了一些特殊的特性。我们先来过一下用到的特性:

我们知道 ReturnType 是 ts 内置的一个高级类型,它可以取到函数返回值的类型。但如果这个函数有多个重载呢?

第一种重载方式:

image

第二种重载方式:

image

第三种重载方式:

image

取重载函数的 ReturnType 返回的是最后一个重载的返回值类型。

但这与联合类型有什么关系呢?

重载函数不是能通过函数交叉的方式写么,而我们又能实现联合转交叉。

所以就能拿到联合类型的最后一个类型:

ts
type UnionToIntersection<U> = (U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown ? R : never;

type UnionToFuncIntersection<T> = UnionToIntersection<T extends any ? () => T : never>;

UnionToIntersection 的实现在套路六里讲了,忘了的可以去翻一下。

这里简单讲一下:U extends U 是触发分布式条件类型,构造一个函数类型,通过模式匹配提取参数的类型,利用函数参数的逆变的性质,就能实现联合转交叉。

因为函数参数的类型要能接收多个类型,那肯定要定义成这些类型的交集,所以会发生逆变,转成交叉类型。

然后是 UnionToFuncIntersection 的类型:

我们对联合类型 T 做下处理,用 T extends any 触发分布式条件类型的特性,它会把联合类型的每个类型单独传入做计算,最后把计算结果合并成联合类型。把每个类型构造成一个函数类型传入。

这样,返回的交叉类型也就达到了函数重载的目的:

image

然后再通过 ReturnType 取返回值的类型,就取到了联合类型的最后一个类型:

image

取到最后一个类型后,再用 Exclude 从联合类型中把它去掉,然后再同样的方式取最后一个类型,构造成元组类型返回,这样就达到了联合转元组的目的:

ts
type UnionToTuple<T> = UnionToIntersection<T extends any ? () => T : never> extends () => infer ReturnType
  ? [...UnionToTuple<Exclude<T, ReturnType>>, ReturnType]
  : [];

类型参数 T 为待处理的联合类型。

T extends any 触发了分布式条件类型,会把每个类型单独传入做计算,把它构造成函数类型,然后转成交叉类型,达到函数重载的效果。

通过模式匹配提取出重载函数的返回值类型,也就是联合类型的最后一个类型,放到数组里。

通过 Exclude 从联合类型中去掉这个类型,然后递归的提取剩下的。

这样就完成了联合转元组的目的:

image

试一下

回过头来看一下,联合类型的处理之所以麻烦,是因为不能直接 infer 来取其中的某个类型,我们是利用了取重载函数的返回值类型拿到的是最后一个重载类型的返回值这个特性,把联合类型转成交叉类型来构造重载函数,然后取返回值类型的方式来取到的最后一个类型。然后加上递归,就实现了所有类型的提取。

join

不知道大家是否还记得 "类型编程的意义" 那节的 currying 函数的类型定义,那个还是挺复杂的。我们再来做个类似的练习。

ts
const res = join("-")("guang", "and", "dong");

有这样一个 join 函数,它是一个高阶函数,第一次调用传入分隔符,第二次传入多个字符串,然后返回它们 join 之后的结果。

比如上面的 res 是 guang-and-dong。

如果要给这样一个 join 函数加上类型定义应该怎么加呢?要求精准的提示函数返回值的类型。

image

我们可以这样来定义这个类型:

ts
declare function join<Delimiter extends string>(
  delimiter: Delimiter
): <Items extends string[]>(...parts: Items) => JoinType<Items, Delimiter>;

类型参数 Delimiter 是第一次调用的参数的类型,约束为 string。

join 的返回值是一个函数,也有类型参数。类型参数 Items 是返回的函数的参数类型。

返回的函数类型的返回值是 JoinType 的计算结果,传入两次函数的参数 Delimiter 和 Items。

这里的 JoinType 的实现就是根据字符串元组构造字符串,用到提取和构造,因为数量不确定,还需要递归。

所以 JoinType 高级类型的实现就是这样的:

ts
type JoinType<Items extends any[], Delimiter extends string, Result extends string = ""> = Items extends [
  infer Cur,
  ...infer Rest
]
  ? JoinType<Rest, Delimiter, `${Result}${Delimiter}${Cur & string}`>
  : RemoveFirstDelimiter<Result>;

类型参数 Items 和 Delimiter 分别是字符串元组和分割符的类型。Result 是用于在递归中保存中间结果的。

通过模式匹配提取 Items 中的第一个元素的类型到 infer 声明的局部变量 Cur,后面的元素的类型到 Rest。

构造字符串就是在之前构造出的 Result 的基础上,加上新的一部分 Delimiter 和 Cur,然后递归的构造。这里提取出的 Cur 是 unknown 类型,要 & string 转成字符串类型。

如果不满足模式匹配,也就是构造完了,那就返回 Result,但是因为多加了一个 Delimiter,要去一下。

ts
type RemoveFirstDelimiter<Str extends string> = Str extends `${infer _}${infer Rest}` ? Rest : Str;

去掉开始的 Delimiter 就是个简单字符串字面量类型的提取,就不多解释了。

这样,就实现了 join 的类型定义:

image

试一下

索引类型是我们处理最多的类型,再来练习个索引类型的:

DeepCamelize

Camelize 是 guang-and-dong 转 guangAndDong,这个我们上节实现过。现在要求递归的把索引类型的 key 转成 CamelCase 的。

比如这样一个索引类型:

ts
type obj = {
  aaa_bbb: string;
  bbb_ccc: [
    {
      ccc_ddd: string;
    },
    {
      ddd_eee: string;
      eee_fff: {
        fff_ggg: string;
      };
    }
  ];
};

要求转成这样:

ts
type DeepCamelizeRes = {
  aaaBbb: string;
  bbbCcc: [
    {
      cccDdd: string;
    },
    {
      dddEee: string;
      eeeFff: {
        fffGgg: string;
      };
    }
  ];
};

这要求在 KebabCase 转 CamelCase 的基础上,加上索引类型的递归处理,比较综合。

我们实现下:

ts
type DeepCamelize<Obj extends Record<string, any>> = Obj extends unknown[]
  ? CamelizeArr<Obj>
  : {
      [Key in keyof Obj as Key extends `${infer First}_${infer Rest}`
        ? `${First}${Capitalize<Rest>}`
        : Key]: DeepCamelize<Obj[Key]>;
    };

类型参数 Obj 为待处理的索引类型,约束为 Record<string, any>。

判断下是否是数组类型,如果是的话,用 CamelizeArr 处理。

否则就是索引类型,用映射类型的语法来构造新的索引类型,Key 为之前的 Key,也就是 Key in keyof Obj,但要做一些变化,也就是 as 重映射之后的部分。

这里的 KebabCase 转 CamelCase 就是提取 _ 之前的部分到 First,之后的部分到 Rest,然后构造新的字符串字面量类型,对 Rest 部分做首字母大写,也就是 Capitialize。

值的类型 Obj[Key] 要递归的处理,也就是 DeepCamelize<Obj[Key]>。

其中的 CamelizeArr 的实现就是递归处理每一个元素:

ts
type CamelizeArr<Arr> = Arr extends [infer First, ...infer Rest] ? [DeepCamelize<First>, ...CamelizeArr<Rest>] : [];

通过模式匹配提取 Arr 的第一个元素的类型到 First,剩余元素的类型到 Rest。

处理 First 放到数组中,剩余的递归处理。

这样我们就实现了索引类型的递归 Camelize:

image

试一下

递归是很常用的套路,比如之前遇到一个这样的需求:

AllKeyPath

需求是拿到一个索引类型的所有 key 的路径。

比如这样的索引类型:

ts
type Obj = {
  a: {
    b: {
      b1: string;
      b2: string;
    };
    c: {
      c1: string;
      c2: string;
    };
  };
};

希望返回 a、a.b、a.b.b1、a.b.b2、a.c、a.c.c1、a.c.c2 这些全部的 path。

这里需要遍历 Key,用映射类型的语法,然后要递归构造 path,最后取所有 key 的遍历结果。

也就是这样写:

ts
type AllKeyPath<Obj extends Record<string, any>> = {
  [Key in keyof Obj]: Key extends string
    ? Obj[Key] extends Record<string, any>
      ? Key | `${Key}.${AllKeyPath<Obj[Key]>}`
      : Key
    : never;
}[keyof Obj];

参数 Obj 是待处理的索引类型,通过 Record<string, any> 约束。

用映射类型的语法,遍历 Key,并在 value 部分根据每个 Key 去构造以它为开头的 path。

因为推导出来的 Key 默认是 unknown,而其实明显是个 string,所以 Key extends string 判断一下,后面的分支里 Key 就都是 string 了。

如果 Obj[Key] 依然是个索引类型的话,就递归构造,否则,返回当前的 Key。

我们最终需要的是 value 部分,所以取 [keyof Obj] 的值。keyof Obj 是 key 的联合类型,那么传入之后得到的就是所有 key 对应的 value 的联合类型。

这样就完成了所有 path 的递归生成:

image

试一下

最后再练习下内置的高级类型,这些比较常用:

Defaultize

实现这样一个高级类型,对 A、B 两个索引类型做合并,如果是只有 A 中有的不变,如果是 A、B 都有的就变为可选,只有 B 中有的也变为可选。

比如下面这样:

image

aaa 是只有 A 有的,所以不变。

bbb 是两者都有的,变为可选。

ccc 是只有 B 有的,变为可选。

怎么实现这样的高级类型呢?

索引类型处理可以 Pick 出每一部分单独处理,最后取交叉类型来把处理后的索引类型合并到一起。

上面的类型就可以这样实现:

ts
type Defaultize<A, B> = Pick<A, Exclude<keyof A, keyof B>> &
  Partial<Pick<A, Extract<keyof A, keyof B>>> &
  Partial<Pick<B, Exclude<keyof B, keyof A>>>;

Pick 出 A、B 中只有 A 有的部分,也就是去 A 中去掉了 B 的 key: Exclude<keyof A, keyof B>。

然后 Pick 出 A、B 都有的部分,也就是 Extract<keyof A, keyof B>。用 Partial 转为可选。

之后 Pick 出只有 B 有的部分,也就是 Exclude<keyof B, keyof A>。用 Partial 转为可选。

最后取交叉类型来把每部分的处理结果合并到一起。

这样就实现了我们的需求:

image

为啥这里没显示最终的类型呢?

因为 ts 只有在类型被用到的时候才会去做类型计算,根据这个特点,我们可以用映射类型的语法构造一个一摸一样的索引类型来触发类型计算。

ts
type Copy<Obj extends Record<string, any>> = {
  [Key in keyof Obj]: Obj[Key];
};

这就是标准的映射类型的语法,就不多解释了。

这样就能看到最终的类型:

image

试一下

总结

这节我们又做了很多高难度的案例,提高练习的难度才能更好的提升类型编程水平。

首先我们学会了函数重载的三种写法,这个是为后面的联合转元组做铺垫的,联合转元组利用了提取重载函数的返回值会返回最后一个重载的返回值类型的特性,通过联合转交叉构造出重载类型来提取的联合类型中的类型,然后递归的处理。

之后我们实现了 join 的类型定义,这个综合用到了元组的提取和字符串字面量类型的构造,再加上递归。

然后我们又实现了 DeepCamelize,递归的处理索引类型,把 Key 转为 CamelCase 的形式,比较综合的案例。

之后实现了 AllKeyPath 的类型,通过映射类型遍历 Key,用它来递归构造不同的 Path,最后取 value,就是结果。

最后练习了内置的高级类型 Pick、Exclude、Extract、Partial 等,处理索引类型的常用套路就是 Pick 出每一部分单独做处理,最后取交叉类型把结果合并到一起

能够实现这些高难度的类型,相信你的类型编程水平就已经很不错了。

本文案例的合并